useCallback と useRef を使用して不要な更新を防ぐ
from Refactoring with Hooks
カスタムフックを使った機能拡張 の問題点
スライドが進んだこと 以外 による再レンダリングでも incrementSlideIndex 関数が新しく生成される
その都度 タイマー がリセットされる
以下のテストを実行すると失敗し、この問題が確認できる
code:Carousel.test.tsx
it("does not reset the auto-advance timer on re-render", () => {
const autoAdvanceInterval = 5_000;
const { rerender } = render(
<Carousel slides={slides} autoAdvanceInterval={autoAdvanceInterval} />,
);
const img = screen.getByRole("img");
expect(img).toHaveAttribute("src", slides0.imgUrl);
act(() => {
vi.advanceTimersByTime(autoAdvanceInterval - 1);
});
expect(img).toHaveAttribute("src", slides0.imgUrl);
rerender(
<Carousel slides={slides} autoAdvanceInterval={autoAdvanceInterval} />,
);
act(() => {
vi.advanceTimersByTime(1);
});
expect(img).toHaveAttribute("src", slides1.imgUrl);
});
問題発生フロー
1. Carousel が再レンダリングされると useSlideIndex が呼び出される
2. 新しい incrementSlideIndex 関数が生成され、useTimeout に渡される
新しいかどうかは ===(Shallow comparison)を用いる
3. React は依存関係が更新されたと認識し、useEffect 内で return している関数を呼び出し、clearTimeout を実行する
4. setTimeout を実行し、新しいタイマーが生成される
解決策
1. React.memo を用いる
親コンポーネントが同じ Props を渡しても、Carousel が再レンダリングされないようにできる
しかし、defaultImgHeight や DefaultImgComponent など別の Props を変更すると、再レンダリングされる
2. useCallback を用いる
useEffect と同様に 2 つの引数を取る
1 つ目が関数、2 つ目が依存関係の配列
関数を実行する代わりにその関数を返し、依存関係が変わらない限り関数を返す
これにより、同じ関数を メモ化 できる
code:useSlideIndex.tsx
const incrementSlideIndex = useCallback(() => {
if (!slides) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChange?.(increment(slides.length)(slideIndex));
}, slides, slideIndex, onSlideIndexChange);
依存関係に起因するバグ
上記の実装では、親コンポーネントが slides や onSlideIndexChange の値を再レンダリング間でキャッシュしなければ、問題がまた発生する
以下のテストを実行すると失敗し、この問題が確認できる
code:Carousel.test.tsx
it("does not reset the timer on irrelevant prop changes", () => {
const autoAdvanceInterval = 5_000;
const CarouselParent = () => (
<Carousel
slides={...slides} // 新しい配列が生成される
onSlideIndexChange={vi.fn()} // 新しい関数が生成される
autoAdvanceInterval={autoAdvanceInterval}
/>
);
const { rerender } = render(<CarouselParent />);
const img = screen.getByRole("img");
expect(img).toHaveAttribute("src", slides0.imgUrl);
act(() => {
vi.advanceTimersByTime(autoAdvanceInterval - 1);
});
expect(img).toHaveAttribute("src", slides0.imgUrl);
rerender(<CarouselParent />);
act(() => {
vi.advanceTimersByTime(1);
});
expect(img).toHaveAttribute("src", slides1.imgUrl);
});
CarouselParent がレンダリングされるたびに、slides は新しい配列として、onSlideIndexChange は新しい関数として生成される
これにより、useCallback はそのたびに新しい関数を生成する
解決策
warning.icon 安易に依存関係から削除してはダメ: react-hooks/exhaustive-deps
slides と onSlideIndexChange の依存関係がどのように利用されているか考える
code:useSlideIndex.tsx
const incrementSlideIndex = useCallback(() => {
if (!slides) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChange?.(increment(slides.length)(slideIndex));
}, slides, slideIndex, onSlideIndexChange);
slides
スライドインデックスの境界を判定するためだけに利用
重要なのは slides.length
onSlideIndexChange
最新のものを呼び出したいが、onSlideIndexChange が変更されることによる副作用は起こしたくない
onSlideIndexChange をキャッシュして、incrementSlideIndex の中で使用することで対応できる
それぞれの依存関係について対処する
slides
code:useSlideIndex.tsx
const incrementSlideIndex = useCallback(() => {
if (!slides?.length) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChange?.(increment(slides.length)(slideIndex));
}, slides?.length, slideIndex, onSlideIndexChange);
onSlideIndexChange
useRef を用いる
code:useSlideIndex.tsx
const onSlideIndexChangeRef = useRef(onSlideIndexChange);
onSlideIndexChangeRef.current = onSlideIndexChange;
const incrementSlideIndex = useCallback(() => {
if (!slides?.length) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChangeRef.current?.(increment(slides.length)(slideIndex));
}, slides?.length, slideIndex);
onSlideIndexChangeRef は useRef によって常に同じオブジェクトが返されるので、依存関係に入れる必要はない